# 대표적인 소프트웨어 아키텍처 패턴 알아보기 - 헥사고날 아키텍처
애플리케이션은 데이터베이스에 있는 데이터를 그저 옮겨주는 수동적인 소프트웨어를 넘어 보다 적극적이고, 여러 일을 할 수 있어야 합니다.
데이터베이스 같은 외부 시스템이 애플리케이션의 중심이 아니라, 애플리케이션이 "사용하는" 일부가 되어야 합니다. 상황에 따라 RDBMS에서 NoSQL을 쓸 수도 있고, API 서버와 통신 방식을 Rest에서 GraphQL로 바꿀 수 있어야 합니다.
이렇듯 애플리케이션을 중심으로 보고, DB, 웹 프레임워크 등은 모두 애플리케이션이 사용하는 부품(언제든 갈아끼울 수 있는)으로 보는 아키텍처들이 주목받기 시작했습니다.
대표적인 아키텍처로 헥사고날 아키텍처
와 클린 아키텍처
가 있습니다.
# 개념
(출처: https://livebook.manning.com/book/microservices-patterns/chapter-2/53)
헥사고날(포트 앤 어댑터) 아키텍처는 흔히 육각형의 이미지로 소개되는데, 애플리케이션과 바깥의 모듈들을 자유롭게 탈착 가능하게 하는 것이 골자입니다.
"탈착 가능하다"의 개념은 플레이스테이션 같은 게임기를 생각해보면 더 쉽게 이해가 될 것 같습니다.
게임기에서 가장 중요한 부분은 게임을 실행시키는 본체입니다. 게임기에 연결된 입력 패드나, 게임 화면을 보여주는 모니터는 게임기 본체의 포트와 맞는 규격이면 언제든지 사용자 취향에 따라 바뀔 수 있습니다.
따라서 중요한 것은 게임기 본체 그 자체이고, 그 외부적인 것들은 언제든지 탈부착이 가능합니다.
헥사고날 아키텍처의 핵심도 바로 이와 같습니다. 가운데 애플리케이션을 중심으로 애플리케이션 외의 모듈은 애플리케이션에서 제공하는 포트 모양에 맞다면 언제든 바꿀 수 있도록 하는 것입니다.
헥사고날 아키텍처는 어댑터가 포트 모양만 맞으면 동작하는 것 같다고 해서 포트 앤 어댑터(Ports-and-Adapters)라고 부르기도 합니다. 즉 포트만 맞으면, 어떤 어댑터든 이 포트에 끼울 수 있습니다.
# 구조
이제 헥사고날 아키텍처의 구성 요소를 다음처럼 정리해볼 수 있습니다.
- 도메인
- 레이어드 아키텍처에서의 도메인 레이어 개념과 같습니다.
- 애플리케이션의 핵심이 되는 도메인을 표현합니다.
- 애플리케이션
- 도메인을 이용한 애플리케이션의 비즈니스 로직을 제공합니다.
- 관용적으로
Service
라고 표현하곤 합니다. - 애플리케이션은
포트
를 가지고 있습니다.- 포트는 외부 어댑터를 끼울 수 있는 인터페이스 입니다.
- 위 플레이스테이션 예시에서 게임기 본체가 애플리케이션이고, 본체에 있는 입출력, 혹은 모니터 단자를 끼울 수 있는 포트가 여기서 말하는 포트라고 이해하면 쉽습니다.
- 애플리케이션으로 흐름이 들어오는 포트는 인바운드 포트, 애플리케이션에서 흐름이 나가는 포트는 아웃바운드 포트라고 합니다.
- 포트는 프로그래밍 문법에서 인터페이스로 구현할 수 있습니다.
- 어댑터
- 애플리케이션 내에 있는 포트에 끼울 수 있는 구현체입니다.
- web이나 cli 등은 인바운드 포트에 끼울 수 있는 인바운드 어댑터에서 이용합니다.
- db 등은 아웃바운드 포트에 끼울 수 있는 아웃바운드 어댑터를 통해 이용합니다.
- 어댑터는 보통 포트를 나타내는 인터페이스를 상속받아 구현합니다.
헥사고날 아키텍처에서 레이어 의존성이 다음처럼 흐릅니다.
어댑터 -> 애플리케이션 -> 도메인
위의 의존성 흐름을 역행하면 안됩니다. 예를 들면, 비즈니스 로직 -> 어댑터로, 도메인 -> 어댑터로 흐르는 의존성이 없어야 합니다.
# 예시
헥사고날 아키텍처를 프로젝트 구조로 표현하면 다음과 같습니다.
src/
adapter/
inbound/
api/
product_controller.py
user_controller.py
...
outbound/
repositories/
product_repository.py
user_repository.py
application/
service/
product_service.py
user_service.py
port/
inbound/
product_port.py
user_port.py
outbound/
product_repository.py
user_repository.py
domain/
product.py
user.py
먼저 프로젝트 최상단에서 크게 어댑터, 애플리케이션, 도메인으로 레이어를 디렉토리로 나눕니다. 그리고 각 디렉토리 내에 해당 레이어에 포함되는 컴포넌트들을 배치합니다.
레이어드 아키텍처에서는 다음처럼 서비스 레이어에 있는 모듈이 인프라스트럭쳐에 있는 모듈을 사용할 수 있었습니다.
- as-is
# src/application_layer/product_service.py from src.domain_layer import product from src.infrastructure_layer.database import db from src.infrastructure_layer.repositories import product_repository def create_product(name: str, price: str) -> bool: ... product_repository = product_repository.ProductRepository(session) ...
그러나 헥사고날 아키텍처에서는 이런 흐름은 금지되므로, 다음처럼 코드를 수정해야 합니다.
- to-be
# src/application/service/product_service.py from src.domain import product from src.applicaiton.port.outbound.product_repository import ProductRepository # 이 부분이 수정되었습니다! # 이제 애플리케이션 레이어는 인프라스트럭쳐 레이어가 아닌 애플리케이션 레이어에 의존합니다 def create_product(name: str, price: str, product_repository: ProductRepository) -> bool: ... product_repository.create(...) ...
TIP
포트 앤 어댑터의 의존성 원칙은 저수준이 아닌 고수준에 의존하라는 의존성 역전 원칙과도 동일 선상이라고 볼 수 있습니다.
보통 컴파일 의존성(코드 의존성)에서는 이렇게 고수준을 의존하게 한 후, 런타임에서 의존성을 주입해줍니다(의존성 주입 프레임워크를 많이 활용함)
포트는 다음처럼 인터페이스(추상 클래스)로 구현합니다.
# src/application/port/outbound/product_repository.py
from abc import ABC, abstractmethod
from src.domain.product import Product
class ProductRepository(ABC):
@abstractmethod
def save(product: Product) -> None:
pass
그리고 이 포트를 구현한 클래스는 src/adapter/outbound/repositories/product_repository.py
에 구현합니다.
도메인도 마찬가지로 레이어드 아키텍처에서는 인프라스트럭쳐 레이어의 컴포넌트에 의존했습니다.
- as-is
# src/domain_layer/product.py from sqlalchemy import Column, String, Integer # DB와 연결하는 일은 인프라스트럭처 레이어에서의 일입니다. from src.infrastructure_layer.database import Base # 도메인 레이어의 컴포넌트(Product)는 인프라스트럭쳐 레이어의 컴포넌트(Base)에 의존합니다. class Product(Base): __tablename__ = 'product' id = Column(Integer, primary_key=True) name = Column(String) price = Column(Integer)
이 코드도 이제 도메인이 인프라스트럭쳐에 의존하지 않게 다음처럼 변경할 수 있습니다.
- to-be
# src/domain/product.py from dataclasses import dataclass @dataclass class Product: id: int name: str price: int
어댑터 코드의 경우는 외부 서비스와 연결해주는 인터페이스의 역할을 해주는 코드를 작성해주면 됩니다. 애플리케이션의 Port(고수준)를 구현한 저수준의 코드가 포함됩니다.
# src/adapter/outbound/product_repository.py
...
from src.application.port.outbound.product_repository import ProductRepository
class MysqlProductRepository(ProductRepository):
...
def save(self, name: str, price: int):
product = Product(name, price)
with self.db.Session() as session:
...
session.commit()
...
# 나가며
헥사고날 아키텍처를 통해 우리는 프로젝트의 중요한 부분(애플리케이션과 도메인)과 덜 중요한 부분(어댑터)을 구분하고, 의존성의 방향을 중요한 것으로 흐르게 해서, 덜 중요한 부분은 언제든 바꿀 수 있도록 유연하게 코드를 설계했습니다. 이를 통해 인프라스트럭처 중심의 설계를 하지 않아도 되며, 코드 확장에 대해서도 더 열려있게 되었습니다.
다만 이전보다 어댑터, 포트 등 알아야 할 개념과 보일러 플레이트 코드가 늘어날 수 있다는 단점이 있습니다.